Skip to content

feat: executor tool invocation for js-exec#171

Open
cramforce wants to merge 11 commits intomainfrom
executor
Open

feat: executor tool invocation for js-exec#171
cramforce wants to merge 11 commits intomainfrom
executor

Conversation

@cramforce
Copy link
Copy Markdown
Contributor

@cramforce cramforce commented Mar 27, 2026

Summary

Add executor option to BashOptions that gives JavaScript code running in js-exec access to a tools proxy for calling external tools, and auto-generates bash CLI commands from those tools so they work directly from the shell.

Tool calls are synchronous from the QuickJS sandbox's perspective — they block via SharedArrayBuffer/Atomics while the host resolves them asynchronously.

Integrates with @executor-js/sdk and the official plugins:

  • @executor-js/plugin-graphql — auto-discovers tools from GraphQL schemas via introspection
  • @executor-js/plugin-openapi — auto-discovers tools from OpenAPI specs

Auto-generated CLI commands

Every tool automatically becomes a bash command using the namespace + subcommand pattern (like git commit, gh pr create):

# Inline tools
math add a=1 b=2                          # → {"sum":3}
math add --a 1 --b 2                      # → {"sum":3}
math multiply --a 3 --b 4                 # → {"product":12}

# GraphQL-discovered tools
countries country --code JP               # → {"name":"Japan",...}
countries continents                       # → [{"name":"Africa",...},...]

# OpenAPI-discovered tools (camelCase → kebab-case)
petstore list-pets --status available     # → [{"id":1,"name":"Fido"},...]
petstore get-pet --pet-id 123             # → {"id":123,"name":"Fido"}

# Piping and composition
echo '{"code":"JP"}' | countries country  # piped JSON input
math add a=1 b=2 | jq -r .sum            # → 3
countries countries | jq '.[].name' -r | head -5

# Bash loops
for code in JP US BR; do
  countries country --code "$code" | jq -r .name
done

# Store in variables
result=$(math add a=10 b=20)
echo "$result" | jq .sum  # → 30

# Redirect to files
countries countries | jq -r '.[] | .code + "," + .name' > /tmp/countries.csv

Input modes (Speakeasy-style three-tier precedence)

# 1. Flags (highest priority)
math add --a 1 --b 2
math add --a=1 --b=2
math add a=1 b=2

# 2. --json flag
math add --json '{"a": 1, "b": 2}'

# 3. Piped stdin (lowest priority, overridden by flags)
echo '{"a": 1}' | math add b=2    # → {"sum":3}  (stdin + explicit flag)
cat payload.json | petstore create-pet

Help output (gh CLI style)

$ math --help
Executor tools: math

USAGE
  math <command> [flags]

COMMANDS
  add             Add two numbers
  multiply        Multiply two numbers

EXAMPLES
  math add key=value
  math multiply --key value

LEARN MORE
  math <command> --help
$ math add --help
Add two numbers

USAGE
  math add [key=value ...]
  math add [--key value ...]
  math add --json '{...}'
  <stdin> | math add

FLAGS
  --json string    Pass all arguments as a JSON object
  --help           Show this help

EXAMPLES
  math add key=value
  math add --key value
  math add --json '{"key":"value"}'
  echo '{"key":"value"}' | math add
  math add key=value | jq -r .field

camelCase → kebab-case with aliases

petstore list-pets --status available  # kebab-case (primary)
petstore listPets --status available   # original camelCase also works

Opt-out

const bash = new Bash({
  executor: {
    tools: { ... },
    exposeToolsAsCommands: false,  // tools only available via js-exec
  },
});

js-exec tool proxy (unchanged)

Tools are also available from JavaScript code via the tools proxy:

const bash = new Bash({
  executor: {
    tools: {
      "math.add": {
        description: "Add two numbers",
        execute: (args) => ({ sum: args.a + args.b }),
      },
    },
  },
});

await bash.exec(`js-exec -c '
  const r = await tools.math.add({ a: 3, b: 4 });
  console.log(r.sum);  // 7
'`);

GraphQL tool discovery

const bash = new Bash({
  executor: {
    setup: async (sdk) => {
      await sdk.sources.add({
        kind: "graphql",
        endpoint: "https://countries.trevorblades.com/graphql",
        name: "countries",
      });
    },
  },
});

// Auto-discovered: countries country, countries countries,
// countries continent, countries continents, countries language, countries languages

// Works from bash:
await bash.exec('countries country --code JP | jq -r .data.name');

// Works from js-exec:
await bash.exec(`js-exec -c '
  const jp = await tools.countries.country({ code: "JP" });
  console.log(jp.data.name);
'`);

Offline introspection (no network call during setup):

await sdk.sources.add({
  kind: "graphql",
  endpoint: "https://countries.trevorblades.com/graphql",
  name: "countries",
  introspectionJson: fs.readFileSync("schema.json", "utf8"),
});

OpenAPI tool discovery

const bash = new Bash({
  executor: {
    setup: async (sdk) => {
      await sdk.sources.add({
        kind: "openapi",
        spec: fs.readFileSync("petstore.json", "utf8"),
        endpoint: "https://petstore3.swagger.io/api/v3",
        name: "petstore",
      });
    },
  },
});

// Auto-discovered: petstore list-pets, petstore create-pet, petstore get-pet, ...
await bash.exec('petstore list-pets --status available | jq ".[0].name"');

Tool approval and elicitation

const bash = new Bash({
  executor: {
    setup: async (sdk) => { /* ... */ },

    // Control which tools can run
    onToolApproval: async (request) => {
      if (request.operationKind === "read") return { approved: true };
      return { approved: false, reason: "only reads allowed" };
    },

    // Handle tool requests for user input (default: decline all)
    onElicitation: async (ctx) => {
      if (ctx.request._tag === "UrlElicitation") {
        await openBrowser(ctx.request.url);
        return { action: "accept" };
      }
      return { action: "decline" };
    },
  },
});

Credentials (never exposed to sandbox)

const bash = new Bash({
  executor: {
    setup: async (sdk) => {
      await sdk.sources.add({
        kind: "openapi",
        spec: mySpec,
        endpoint: "https://api.example.com",
        name: "myapi",
        headers: {
          Authorization: `Bearer ${process.env.API_TOKEN}`,
        },
      });
    },
  },
});
// Headers live in host closure → plugin internals → outbound HTTP only.
// Sandboxed code cannot inspect or exfiltrate credentials.

ExecutorConfig API

Option Type Default Description
tools Record<string, { description?; execute }> Inline tool definitions
setup (sdk) => Promise<void> SDK setup: add graphql/openapi/custom sources
plugins any[] Additional SDK plugins
onToolApproval "allow-all" | "deny-all" | callback "allow-all" Controls which tools can run
onElicitation handler | "accept-all" decline all Handles tool input requests
exposeToolsAsCommands boolean true Register tools as bash commands

Supported source kinds

Kind Plugin Config Description
"graphql" @executor-js/plugin-graphql endpoint, name, introspectionJson?, headers? Introspects schema, registers query/mutation tools
"openapi" @executor-js/plugin-openapi spec, endpoint, name, headers? Parses spec, registers operation tools
"custom" built-in name, tools: { [name]: { execute } } Direct tool registration

Implementation details

CLI command generation (new):

  • src/commands/tool-command.ts — argument parser, help formatter, namespace command factory
  • Three-tier input: flags > --json > stdin (modeled after Speakeasy CLI)
  • gh CLI-style help output (USAGE/COMMANDS/FLAGS/EXAMPLES)
  • camelToKebab() for subcommand names, with original camelCase aliases
  • buildNamespaceCommands() groups tools by first dot-segment
  • Inline tools registered synchronously in constructor; SDK tools after ensureExecutorReady()

Tool invocation bridge:

  • New INVOKE_TOOL (400) opcode in the SharedArrayBuffer bridge protocol
  • SyncBackend.invokeTool() — worker-side sync call via Atomics.wait
  • Worker registers __invokeTool native function + tools Proxy when hasExecutorTools is set

SDK integration:

  • executor.setup lazily initializes @executor-js/sdk on first exec() (dynamic import)
  • sources.add() dispatches by kind: "graphql" → plugin-graphql, "openapi" → plugin-openapi, "custom" → discovery plugin
  • GraphQL plugin supports offline introspectionJson
  • Init promise rejects are cleared for retry on next exec()

Security:

  • Inline tool maps use null-prototype objects to prevent prototype pollution
  • Malformed tool arguments throw instead of silently defaulting to undefined
  • onElicitation defaults to declining all requests
  • Credentials in headers config never reach the sandbox

Test plan

  • CLI commands — key=value, --flag, --json, stdin, piping, help, errors, aliases, opt-out (39 tests)
  • Inline tools via js-exec — call, chain, error handling, structured results (13 tests)
  • Custom source discovery — registration, listing, filtering, approval (9 tests)
  • GraphQL discovery — offline introspection JSON, multi-tool discovery (2 tests)
  • OpenAPI discovery — static spec parsing, namespace, multi-operation (2 tests)
  • All 12,828 unit tests pass (362 test files)
  • Typecheck, lint, knip all clean

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
just-bash-website Ready Ready Preview, Comment Apr 10, 2026 6:01pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
just-bash Ignored Ignored Apr 10, 2026 6:01pm

Comment thread src/Bash.ts Outdated
cramforce and others added 9 commits April 10, 2026 07:51
Add `executor` option to `BashOptions` that makes a `tools` proxy available
to JavaScript code running in js-exec. Tool calls are synchronous from the
QuickJS sandbox's perspective — they block via SharedArrayBuffer/Atomics
while the host resolves them asynchronously.

Natively integrates with `@executor/sdk` — the `setup` callback receives
the SDK instance for adding OpenAPI, GraphQL, and MCP sources that
auto-discover tools. `onToolApproval` controls which tools are allowed.

## Native SDK integration (OpenAPI, GraphQL, MCP)

```ts
import { Bash } from "just-bash";

const bash = new Bash({
  executor: {
    setup: async (sdk) => {
      await sdk.sources.add({
        kind: "openapi",
        endpoint: "https://petstore3.swagger.io/api/v3",
        specUrl: "https://petstore3.swagger.io/api/v3/openapi.json",
        name: "petstore",
      });

      await sdk.sources.add({
        kind: "graphql",
        endpoint: "https://countries.trevorblades.com/graphql",
        name: "countries",
      });

      await sdk.sources.add({
        kind: "mcp",
        endpoint: "https://mcp.example.com/sse",
        name: "internal",
        transport: "sse",
      });
    },

    onToolApproval: async (request) => {
      // Auto-approve reads, require confirmation for writes/deletes
      if (request.operationKind === "read") return { approved: true };
      const ok = await promptUser(
        `Allow ${request.toolPath} (${request.operationKind})?`
      );
      return ok ? { approved: true } : { approved: false, reason: "denied" };
    },
  },
});

await bash.exec(`js-exec -c '
  const pets = await tools.petstore.findPetsByStatus({ status: "available" });
  const country = await tools.countries.country({ code: "US" });
  const docs = await tools.internal.searchDocs({ query: "deploy" });
  console.log(pets.length, "pets,", country.name, ",", docs.hits.length, "docs");
'`);
```

## Inline tools (no SDK needed)

```ts
const bash = new Bash({
  executor: {
    tools: {
      "math.add": {
        description: "Add two numbers",
        execute: (args) => ({ sum: args.a + args.b }),
      },
      "db.query": {
        execute: async (args) => {
          const rows = await pg.query(args.sql);
          return { rows };
        },
      },
    },
  },
});

await bash.exec(`js-exec -c '
  const sum = await tools.math.add({ a: 3, b: 4 });
  console.log(sum.sum);

  const data = await tools.db.query({ sql: "SELECT * FROM users" });
  for (const row of data.rows) console.log(row.name);
'`);
```

## Both: inline tools + SDK sources

```ts
const bash = new Bash({
  executor: {
    tools: {
      "util.timestamp": {
        execute: () => ({ ts: Math.floor(Date.now() / 1000) }),
      },
    },
    setup: async (sdk) => {
      await sdk.sources.add({ kind: "openapi", endpoint: "...", specUrl: "...", name: "api" });
    },
    onToolApproval: "allow-all",
  },
});
```

## Implementation

- New INVOKE_TOOL (400) opcode in the SharedArrayBuffer bridge protocol
- SyncBackend.invokeTool() — worker-side sync call via Atomics.wait
- BridgeHandler accepts optional invokeTool callback, handles new opcode
- Worker registers __invokeTool native function + tools Proxy when
  hasExecutorTools is set
- Tool invoker threads from BashOptions → Bash → InterpreterOptions →
  InterpreterContext → CommandContext → js-exec → BridgeHandler → worker
- executor.setup lazily initializes @executor/sdk on first exec
  (dynamic import — SDK is only loaded when setup is provided)
- executor.onToolApproval wired through to createExecutor() — controls
  approval for SDK-discovered tools (inline tools are always allowed)
- SDK's CodeExecutor runtime delegates to js-exec's executeForExecutor
- Full executor mode (log capture + result capture) available via
  executorMode flag for direct SDK integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 10, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​executor-js/​plugin-graphql@​0.0.1-beta.5751009292100
Addednpm/​@​executor-js/​plugin-mcp@​0.0.1-beta.5761009088100
Addednpm/​@​executor-js/​plugin-openapi@​0.0.1-beta.5761009592100
Addednpm/​@​executor-js/​sdk@​0.0.1-beta.57710010092100

View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant